package rewirer

import (
	"fmt"

	"github.com/sourcegraph/sourcegraph/enterprise/internal/batches/global"
	btypes "github.com/sourcegraph/sourcegraph/enterprise/internal/batches/types"
	"github.com/sourcegraph/sourcegraph/internal/database"
	"github.com/sourcegraph/sourcegraph/internal/types"
)

type ChangesetRewirer struct {
	// The mappings need to be hydrated for the ChangesetRewirer to consume them.
	mappings      btypes.RewirerMappings
	batchChangeID int64
}

func New(mappings btypes.RewirerMappings, batchChangeID int64) *ChangesetRewirer {
	return &ChangesetRewirer{
		mappings:      mappings,
		batchChangeID: batchChangeID,
	}
}

// Rewire uses RewirerMappings (mapping ChangesetSpecs to matching Changesets) generated by Store.GetRewirerMappings to update the Changesets
// for consumption by the background reconciler.
//
// It also updates the ChangesetIDs on the batch change.
func (r *ChangesetRewirer) Rewire() (changesets []*btypes.Changeset, err error) {
	changesets = []*btypes.Changeset{}

	for _, m := range r.mappings {
		// If a Changeset that's currently attached to the batch change wasn't matched to a ChangesetSpec, it needs to be closed/detached.
		if m.ChangesetSpec == nil {
			changeset := m.Changeset

			// If we don't have access to a repository, we don't detach nor close the changeset.
			if m.Repo == nil {
				continue
			}

			// If the changeset is currently not attached to this batch change, we don't want to modify it.
			if !changeset.AttachedTo(r.batchChangeID) {
				continue
			}

			r.closeChangeset(changeset)
			changesets = append(changesets, changeset)

			continue
		}

		spec := m.ChangesetSpec

		// If we don't have access to a repository, we return an error. Why not
		// simply skip the repository? If we skip it, the user can't reapply
		// the same batch spec, since it's already applied and re-applying
		// would require a new spec.
		repo := m.Repo
		if repo == nil {
			return nil, &database.RepoNotFoundErr{ID: m.RepoID}
		}

		if err := checkRepoSupported(repo); err != nil {
			return nil, err
		}

		var changeset *btypes.Changeset

		if m.Changeset != nil {
			changeset = m.Changeset
			if spec.Spec.IsImportingExisting() {
				r.attachTrackingChangeset(changeset)
			} else if spec.Spec.IsBranch() {
				r.updateChangesetToNewSpec(changeset, spec)
			}
		} else {
			if spec.Spec.IsImportingExisting() {
				changeset = r.createTrackingChangeset(repo, spec.Spec.ExternalID)
			} else if spec.Spec.IsBranch() {
				changeset = r.createChangesetForSpec(repo, spec)
			}
		}
		changesets = append(changesets, changeset)
	}

	return changesets, nil
}

func (r *ChangesetRewirer) createChangesetForSpec(repo *types.Repo, spec *btypes.ChangesetSpec) *btypes.Changeset {
	newChangeset := &btypes.Changeset{
		RepoID:              spec.RepoID,
		ExternalServiceType: repo.ExternalRepo.ServiceType,

		BatchChanges:         []btypes.BatchChangeAssoc{{BatchChangeID: r.batchChangeID}},
		OwnedByBatchChangeID: r.batchChangeID,

		PublicationState: btypes.ChangesetPublicationStateUnpublished,
	}

	newChangeset.SetCurrentSpec(spec)

	// Set up the initial queue state of the changeset.
	newChangeset.ResetReconcilerState(global.DefaultReconcilerEnqueueState())

	return newChangeset
}

func (r *ChangesetRewirer) updateChangesetToNewSpec(c *btypes.Changeset, spec *btypes.ChangesetSpec) {
	if c.ReconcilerState == btypes.ReconcilerStateCompleted {
		c.PreviousSpecID = c.CurrentSpecID
	}

	c.SetCurrentSpec(spec)

	// Ensure that the changeset is attached to the batch change
	c.Attach(r.batchChangeID)

	// We need to enqueue it for the changeset reconciler, so the
	// reconciler wakes up, compares old and new spec and, if
	// necessary, updates the changesets accordingly.
	c.ResetReconcilerState(global.DefaultReconcilerEnqueueState())
}

func (r *ChangesetRewirer) createTrackingChangeset(repo *types.Repo, externalID string) *btypes.Changeset {
	newChangeset := &btypes.Changeset{
		RepoID:              repo.ID,
		ExternalServiceType: repo.ExternalRepo.ServiceType,

		BatchChanges: []btypes.BatchChangeAssoc{{BatchChangeID: r.batchChangeID}},
		ExternalID:   externalID,
		// Note: no CurrentSpecID, because we merely track this one

		PublicationState: btypes.ChangesetPublicationStateUnpublished,

		// Enqueue it so the reconciler syncs it.
		ReconcilerState: btypes.ReconcilerStateQueued,
	}

	return newChangeset
}

func (r *ChangesetRewirer) attachTrackingChangeset(changeset *btypes.Changeset) {
	// We already have a changeset with the given repoID and
	// externalID, so we can track it.
	changeset.Attach(r.batchChangeID)

	// If it's errored and not created by another batch change, we re-enqueue it.
	if changeset.OwnedByBatchChangeID == 0 && (changeset.ReconcilerState == btypes.ReconcilerStateErrored || changeset.ReconcilerState == btypes.ReconcilerStateFailed) {
		changeset.ResetReconcilerState(global.DefaultReconcilerEnqueueState())
	}
}

func (r *ChangesetRewirer) closeChangeset(changeset *btypes.Changeset) {
	reset := false
	if changeset.CurrentSpecID != 0 && changeset.OwnedByBatchChangeID == r.batchChangeID && changeset.Published() {
		// If we have a current spec ID and the changeset was created by
		// _this_ batch change that means we should archive it.

		// Store the current spec also as the previous spec.
		//
		// Why?
		//
		// When a changeset with (prev: A, curr: B) should be closed but
		// closing failed, it will still have (prev: A, curr: B) set.
		//
		// If someone then applies a new batch spec and re-attaches that
		// changeset with changeset spec C, the changeset would end up with
		// (prev: A, curr: C), because we don't rotate specs on errors in
		// `updateChangesetToNewSpec`.
		//
		// That would mean, though, that the delta between A and C tells us
		// to repush and update the changeset on the code host, in addition
		// to 'reopen', which would actually be the only required action.
		//
		// So, when we mark a changeset as to-be-closed, we also rotate the
		// specs, so that it changeset is saved as (prev: B, curr: B) and
		// when somebody re-attaches it it's (prev: B, curr: C).
		// But we only rotate the spec, if applying the currentSpecID was
		// successful:
		if changeset.ReconcilerState == btypes.ReconcilerStateCompleted {
			changeset.PreviousSpecID = changeset.CurrentSpecID
		}

		// If we're here we want to archive the changeset or it's archived
		// already and we don't want to detach it.
		if !changeset.ArchivedIn(r.batchChangeID) {
			changeset.Archive(r.batchChangeID)
			reset = true

			// If the changeset hasn't been closed/merged yet, we close it.
			// Marking it as Closing would be a noop, but it's weird to show a
			// changeset as will-be-closed on the preview page when it's
			// already closed.
			if changeset.Closeable() {
				changeset.Closing = true
			}
		}
	} else if wasAttached := changeset.Detach(r.batchChangeID); wasAttached {
		// If not, we simply detach it
		reset = true
	}

	if reset {
		changeset.ResetReconcilerState(global.DefaultReconcilerEnqueueState())
	}
}

// ErrRepoNotSupported is thrown by the rewirer when it encounters a mapping
// targetting a repo on a code host that's not supported by batches.
type ErrRepoNotSupported struct {
	ServiceType string
	RepoName    string
}

func (e ErrRepoNotSupported) Error() string {
	return fmt.Sprintf(
		"Code host type %s of repository %q is currently not supported for use with Batch Changes",
		e.ServiceType,
		e.RepoName,
	)
}

var _ error = ErrRepoNotSupported{}

// checkRepoSupported checks whether the given repository is supported by batch
// changes and if not it returns an error.
func checkRepoSupported(repo *types.Repo) error {
	if btypes.IsRepoSupported(&repo.ExternalRepo) {
		return nil
	}

	return &ErrRepoNotSupported{
		ServiceType: repo.ExternalRepo.ServiceType,
		RepoName:    string(repo.Name),
	}
}
